Skip to content

Allow owner/repo as repoid if unique#421

Closed
ChristopherHX wants to merge 25 commits into
cloudbase:mainfrom
ChristopherHX:allow-repo-name-as-id
Closed

Allow owner/repo as repoid if unique#421
ChristopherHX wants to merge 25 commits into
cloudbase:mainfrom
ChristopherHX:allow-repo-name-as-id

Conversation

@ChristopherHX

Copy link
Copy Markdown
Contributor

This one is so much easier on the client, the problem here is owner/repo contains a slash.

The slash is escaped, gorilla and several reverse proxies will merge %2f as / and change the url target.

Disabling this somehow looks awful, and may not work correctly depending what is between garm and the cli client

  • UseEncodedPath disables path merging of %2f escape for /
  • unescapeVars reverts the missing decoding after the api is called
  • GetRepository allows to be called with an empty endpointName, returns an error if it is not unique

This does not feel right, unless moving repoid out of the PATH

Signed-off-by: Christopher Homberger <christopher.homberger@web.de>
@ChristopherHX

Copy link
Copy Markdown
Contributor Author

Example

bin/garm-cli repository show test2/test
+----------------------+------------------------------------------------------------------------------------------------------+
| FIELD                | VALUE                                                                                                |
+----------------------+------------------------------------------------------------------------------------------------------+
| ID                   | a8dc50bb-b53a-4d2e-a1a9-3debbf608fda                                                                 |
| Created At           | 2025-05-29 18:06:57.27944 +0200 CEST                                                                 |
| Updated At           | 2025-05-29 20:56:42.017717 +0200 CEST                                                                |
| Owner                | test2                                                                                                |
| Name                 | test                                                                                                 |
| Endpoint             | gitea                                                                                                |
| Pool balancer type   | roundrobin                                                                                           |
| Credentials          | gitea-owner                                                                                          |
| Pool manager running | true                                                                                                 |
| Pools                | a40d66ec-8ea6-499a-91d4-46b48d252d65                                                                 |
|                      | acbbf04b-b621-4df9-8910-914dbd3fadbb                                                                 |
|                      | 12d4953b-83c8-4765-bdbc-8d10e5e1ed1d                                                                 |
|                      | 38784e39-67af-43cb-94dc-57ea915b61b3                                                                 |
|                      | bb6a1534-d386-4faf-aa78-af7da35afce9                                                                 |
|                      | fdc499ea-86ae-4393-b94f-7ad86ec8ce09                                                                 |
|                      | 52769269-2512-4f05-9f50-7251566e02ed                                                                 |
|                      | fddcf917-c810-4aa6-9743-c52ab291001a                                                                 |
| Events               | 2025-05-29T18:06:58 INFO: successfully updated tools                                                 |
|                      | 2025-05-29T18:11:57 ERROR: failed to consolidate runner state: "listing runners: failed to construct |
|                      | request: failed to update token: failed to fetch runner registration token: Post \"http://192.168.17 |
|                      | 8.146:3000/api/v1/repos/test2/test/actions/runners/registration-token\": dial tcp 192.168.178.146:30 |
|                      | 00: connect: connection refused"                                                                     |
|                      | 2025-05-29T18:16:57 ERROR: failed to consolidate runner state: "listing runners: failed to construct |
|                      | request: failed to update token: failed to fetch runner registration token: Post \"http://192.168.17 |
|                      | 8.146:3000/api/v1/repos/test2/test/actions/runners/registration-token\": dial tcp 192.168.178.146:30 |
|                      | 00: connect: connection refused"                                                                     |
|                      | 2025-05-29T18:33:44 ERROR: failed to consolidate runner state: "listing runners: failed to construct |
|                      | request: failed to update token: failed to fetch runner registration token: Post \"http://192.168.17 |
|                      | 8.146:3000/api/v1/repos/test2/test/actions/runners/registration-token\": dial tcp 192.168.178.146:30 |
|                      | 00: connect: connection refused"                                                                     |
|                      | 2025-05-29T18:38:44 ERROR: failed to consolidate runner state: "listing runners: failed to construct |
|                      | request: failed to update token: failed to fetch runner registration token: Post \"http://192.168.17 |
|                      | 8.146:3000/api/v1/repos/test2/test/actions/runners/registration-token\": dial tcp 192.168.178.146:30 |
|                      | 00: connect: connection refused"                                                                     |
|                      | 2025-05-29T18:43:44 ERROR: failed to consolidate runner state: "listing runners: failed to construct |
|                      | request: failed to update token: failed to fetch runner registration token: Post \"http://192.168.17 |
|                      | 8.146:3000/api/v1/repos/test2/test/actions/runners/registration-token\": dial tcp 192.168.178.146:30 |
|                      | 00: connect: connection refused"                                                                     |
|                      | 2025-05-29T18:48:44 ERROR: failed to consolidate runner state: "listing runners: failed to construct |
|                      | request: failed to update token: failed to fetch runner registration token: Post \"http://192.168.17 |
|                      | 8.146:3000/api/v1/repos/test2/test/actions/runners/registration-token\": dial tcp 192.168.178.146:30 |
|                      | 00: connect: connection refused"                                                                     |
|                      | 2025-05-29T18:53:44 ERROR: failed to consolidate runner state: "listing runners: failed to construct |
|                      | request: failed to update token: failed to fetch runner registration token: Post \"http://192.168.17 |
|                      | 8.146:3000/api/v1/repos/test2/test/actions/runners/registration-token\": dial tcp 192.168.178.146:30 |
|                      | 00: connect: connection refused"                                                                     |
|                      | 2025-05-29T18:58:44 ERROR: failed to consolidate runner state: "listing runners: failed to construct |
|                      | request: failed to update token: failed to fetch runner registration token: Post \"http://192.168.17 |
|                      | 8.146:3000/api/v1/repos/test2/test/actions/runners/registration-token\": dial tcp 192.168.178.146:30 |
|                      | 00: connect: connection refused"                                                                     |
|                      | 2025-05-29T19:03:44 ERROR: failed to consolidate runner state: "listing runners: failed to construct |
|                      | request: failed to update token: failed to fetch runner registration token: Post \"http://192.168.17 |
|                      | 8.146:3000/api/v1/repos/test2/test/actions/runners/registration-token\": dial tcp 192.168.178.146:30 |
|                      | 00: connect: connection refused"                                                                     |
|                      | 2025-05-29T19:08:09 INFO: successfully updated tools                                                 |
|                      | 2025-05-29T19:09:32 INFO: successfully updated tools                                                 |
|                      | 2025-05-29T19:13:15 INFO: successfully updated tools                                                 |
|                      | 2025-05-29T19:14:22 INFO: successfully updated tools                                                 |
|                      | 2025-05-29T19:16:12 INFO: successfully updated tools                                                 |
|                      | 2025-05-29T19:19:09 INFO: successfully updated tools                                                 |
|                      | 2025-05-29T19:46:21 ERROR: failed to consolidate runner state: "listing runners: failed to construct |
|                      | request: failed to update token: failed to fetch runner registration token: context canceled"        |
|                      | 2025-05-29T19:46:27 INFO: successfully updated tools                                                 |
|                      | 2025-05-29T19:47:07 INFO: successfully updated tools                                                 |
|                      | 2025-05-29T19:50:28 INFO: successfully updated tools                                                 |
|                      | 2025-05-29T19:55:04 INFO: successfully updated tools                                                 |
|                      | 2025-05-29T20:00:01 ERROR: failed to consolidate runner state: "listing runners: failed to construct |
|                      | request: failed to update token: failed to fetch runner registration token: Post \"http://192.168.17 |
|                      | 8.146:3000/api/v1/repos/test2/test/actions/runners/registration-token\": dial tcp 192.168.178.146:30 |
|                      | 00: connect: connection refused"                                                                     |
|                      | 2025-05-29T20:05:01 ERROR: failed to consolidate runner state: "listing runners: failed to construct |
|                      | request: failed to update token: failed to fetch runner registration token: Post \"http://192.168.17 |
|                      | 8.146:3000/api/v1/repos/test2/test/actions/runners/registration-token\": dial tcp 192.168.178.146:30 |
|                      | 00: connect: connection refused"                                                                     |
|                      | 2025-05-29T20:06:53 INFO: successfully updated tools                                                 |
|                      | 2025-05-29T20:51:45 INFO: successfully updated tools                                                 |
|                      | 2025-05-29T20:56:42 ERROR: failed to consolidate runner state: "listing runners: failed to construct |
|                      | request: failed to update token: failed to fetch runner registration token: Post \"http://192.168.17 |
|                      | 8.146:3000/api/v1/repos/test2/test/actions/runners/registration-token\": dial tcp 192.168.178.146:30 |
|                      | 00: connect: connection refused"                                                                     |
+----------------------+------------------------------------------------------------------------------------------------------+

@ChristopherHX

Copy link
Copy Markdown
Contributor Author
garm % bin/garm-cli pool add --provider-name kubernetes_external --image localhost/void:latest --flavor no --tags ubuntu-latest -r test2/test
+--------------------------+---------------------------------------+
| FIELD                    | VALUE                                 |
+--------------------------+---------------------------------------+
| ID                       | bcc36d55-6822-49f1-9a28-ee16184322dd  |
| Created At               | 2025-05-29 21:02:17.477989 +0200 CEST |
| Updated At               | 2025-05-29 21:02:17.478067 +0200 CEST |
| Provider Name            | kubernetes_external                   |
| Priority                 | 0                                     |
| Image                    | localhost/void:latest                 |
| Flavor                   | no                                    |
| OS Type                  | linux                                 |
| OS Architecture          | amd64                                 |
| Max Runners              | 5                                     |
| Min Idle Runners         | 1                                     |
| Runner Bootstrap Timeout | 20                                    |
| Tags                     | ubuntu-latest                         |
| Belongs to               | test2/test                            |
| Level                    | repo                                  |
| Enabled                  | false                                 |
| Runner Prefix            | garm                                  |
| Extra specs              |                                       |
| GitHub Runner Group      |                                       |
| Forge Type               |                                       |
| Endpoint Name            |                                       |
+--------------------------+---------------------------------------+

@gabriel-samfira

Copy link
Copy Markdown
Member

Rather than escape and unescape owner/repo, the client can split by / and make a request to get /api/v1/repositories/owner/repo-name. This can be a new URL in the router with a separate handler. For orgs and enterprises you only have the name. The new URL can have a query arg ?forgeType= in case of ambiguity.

This would limit to the client the need to parse things, and the API can have a clear route that deals with entities by name, while still allowing for a forgeType to be specified.

What do you think?

@gabriel-samfira

Copy link
Copy Markdown
Member

for pools you can try to parse the argument as a UUID. If it fails, we fall back to friendly name parsing. Decide which API call to make based on the argument, in the client.

@ChristopherHX

Copy link
Copy Markdown
Contributor Author

@gabriel-samfira How do you regenerate swagger from code?

@gabriel-samfira

Copy link
Copy Markdown
Member

To regenerate the swagger code, you can run:

go generate ./...

If you add a new type, you should also update: https://github.com/cloudbase/garm/blob/main/apiserver/swagger.yaml

Comment thread apiserver/controllers/instances.go Outdated
vars := mux.Vars(r)
repoID, ok := vars["repoID"]

repo, ok := a.GetRepository(w, r)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's okay to have some duplicate code, if we can skip doing an extra query. There are people using GARM at scale (hundreds of runners per second in some cases), adding extra queries increases DB queries and load.

It's also desirable to have separate functions for getting by ID versus getting by owner/name. Branching removes duplication at the expense of clarity and flexibility.

So having a ListInstancesForRepositoryByNameHandler(), which has a specific route that is tied to it, is okay. It also allows us to refactor it later without touching the bits that use the ID instead of the owner/name.

It also means having a distinct type in the client code generated by swagger. We want to keep the ability to query by ID alone from the client.

So in summary:

  • Existing routes and handlers can remain unchanged
  • New handlers and routes can be added to handle the cases where names are used instead of IDs
  • For the handlers with names, we also need to be able to specify a query arg indicating forgeType and/or endpointName in case of multiple results.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's okay to have some duplicate code

The duplicated code became to much for me to handle. The swagger duplication was an adventure.

e.g. The shared code had a bug, now I could just fix it everywhere by updating a single line

How it was, I had to change 12+ variants, some of them were sightly different.

I have no idea how you can maintain the duplicated code

In Gitea I saw ugly bugs due to duplicated code, e.g. editwebhook was broken for newly added events.

In c++ I used templates once to have 10% of the codebase than required for duplicated code.

Maybe Duplicate the a.GetRepository(w, r) and use a.GetRepositoryID(w, r) here if only repoID is needed skip the db query.

You can always inline selected functions again if you have a need for special handling

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You make good points. My apologies for the long reply. I like in depth discussions on some things, so bare with me (I wouldn't blame you if you just /dev/null the whole thing 😅).

The swagger duplication was an adventure.

It essentially requires a new handler for each route that wants a friendly name variant. The swagger comments are mostly identical, but for the route and the name of the route (used to generate client code). Parameters also have to be declared. But otherwise, it should be straight forward. It's actually more work to de-duplicate than it is to add new handlers.

e.g. The shared code had a bug, now I could just fix it everywhere by updating a single line

The reverse is also true. You can add a bug everywhere by changing a single line. And it's much easier to do, when it becomes difficult to determine the consequences of a change throughout the different branches of code (you need to be intimately familiar with the code to understand how one change, cascades through deeply branched code - I've had this happen on more complex code bases).

I have no idea how you can maintain the duplicated code

It's not that hard. Handlers are atomic. I need to modify one, when I need to change the way a certain endpoint functions. If I don't, there is no point in touching that code. There are places where de-duplicating is great. There are places where duplication is okay. In presentation code like handlers, some duplication is okay. If you need to do a switch in your controller based on which parameters are present, and you delegate to specific functions the processing of that request, the only thing you're de-duplicating is the swagger stuff. And even that becomes ambiguous when transposed into a generated client library.

It's sometimes difficult to decide when it's okay to duplicate and when it's not, but de-duplicating code is much easier to do than splitting up concerns later on, when the code base becomes spaghetti and you can't make heads or tails of what could break if you make a change somewhere.

There is also the matter of allowing for things to diverge in terms of functionality. Let's take the example of getting a repository. In our scenario, if we use the UUID, there is no ambiguity. The ID is unique and it identifies the database entity without issues. If we want to get by name however, we need to take into account the following:

  • A repository may have the same owner/name on several endpoints
  • Endpoints can be of different types
  • There is the potential that in the future, GARM may have the ability to create users which will have their own endpoints defined
    • Admin users should be able to view other user defined repos

So when filtering by name, we will probably need to allow API calls to send additional query filters like endpoint, endpoint type, user (if you're an admin, you should specify the user that created the entity), etc. But if you use the same handler for the case where an ID is used, your client still allows setting additional filters for that case. Should they cause an error? Should they be ignored? How would the docs for the client look like?

An API endpoint that filters only by ID will most likely never change. It will try to match that ID against the database and will either return the entity or a 404 or 401 error.

An API endpoint that filters by name, needs to take into account multiple factors, which may change over time. This is due to the inherent ambiguity of filtering by something that is not unique. We need to supply a set of parameters that together, guarantee uniqueness. That set of parameters may change over time (unlike the UUID) based on features we may need to add (perhaps multi user support?).

So from an architectural stand point, we have to weigh the benefits of not having an eye bleed, now, due to looking at duplicate code and constantly fighting the urge to hack it with an axe, against future growth where we can keep what is currently functional, and only touch what we care about, without having to think about breaking anything else.

So you have a GetRepoByIDHandler() that services /api/v1/repositories/{repoID} (with no other options) and a GetRepoByFriendlyNameHandler() that services /api/v1/repositories/{owner}/{name} with potential query args that may change over time. The current one may never change. The other one may be changed independently.

I'd much rather have proper test cases for new code (-sobs in little time to add tests-) if the fear is that by duplicating code, we end up with different behavior between old and new code.

All of that being said, I know time is precious (and I am humbled any time someone chooses to use theirs to help). I do not want to burden you with implementing a feature that may have seemed simple to begin with but due to my preferences, or quirks or whatnot, you end up spending a lot of time adding. If you prefer, I can pick this up sometimes in the next 2-3 weeks (if it's not a blocker for you). The user story you presented here is clear (and much easier to use from a UX perspective).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

skipping the other points for now, since my context is limited

It essentially requires a new handler for each route that wants a friendly name variant.

Doesn't this PR has new client side handlers and request parameters? I forget the new swagger comment in e.g the method you commented on.

The swagger func name is different for repoid + owner/repo and query.

I know about this from Gitea, e.g. just two swagger blocks.

Gitea uses a router middleware for access scoping and {owner}/{repo} is resolved in the middleware.

So api endpoints do not have to care about how to get the repository object or access control until you accept a unique database id in a subresource that does not use repoid in the query.
e.g. poolid does not belong to the repository in the url.

If the name is not unique you currently have to provide endpointName in query, changing this after reduplication is harder

@gabriel-samfira gabriel-samfira May 30, 2025

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doesn't this PR has new client side handlers and request parameters?

If the extra swagger comment generates the proper client, it's fine.

The initial comment on this line, was due to the fact that there was a call to a.GetRepository() which queries the DB for the repo, and then gets the ID of the repo to pass on to ListRepoInstances(), where before we got the ID from the route. That has changed now from what I see 😄, so I will have a closer look when the branch is marked as ready for review.

Edit: I don't want to create chaos while things are still in flux.

@ChristopherHX

Copy link
Copy Markdown
Contributor Author

That has changed now from what I see 😄, so I will have a closer look when the branch is marked as ready for review.

There are multiple options for you to choose from, since I didn't force push.

  • Option 1 Single handler (Shared getRepository, reuse old handlers)
    • Multiple Swagger Comments
    • a.GetRepositoryID if you do not need the full repo object
    • a.GetRepository for everything that needs a repo object
  • Option 2 Cloned Handler (Shared getRepository new handlers only)
    • a.GetRepositoryID if you do not need the full repo object
    • a.GetRepository for everything that needs a repo object
  • Option 3 Cloned Handler (No Shared code) (latest commit)

Option 1-3 have the exact same api surface and generated client code, code duplication is the only thing that varies.

@gabriel-samfira How can I run intergration tests locally on ubuntu 25.04 (older ubuntu arm64 didn't had desktop isos for my VM)?
While I changed one of your tests to use the new api endpoints, I do not know if they even work.

This is like on the keda project, it is expected to have several secrets set without guide how to prepare the resources the tests depend on.
There is a lack of what env variables are needed, some of them are only in code and not checked by makefile scripts.

Black box tests that are most of the time fine if the CI blindly runs them for me, but are awkward when testing locally.

Having an automated Gitea instance that could automatically create the required landscape makes it easier for me. Or some scripts that creates the test orgs and repos automatically.

@gabriel-samfira

Copy link
Copy Markdown
Member

There are multiple options for you to choose from, since I didn't force push.

my brain is mush (had too little sleep), but I will have a look soon.

How can I run intergration tests locally on ubuntu 25.04 (older ubuntu arm64 didn't had desktop isos for my VM)?

That's a bit more complicated, because the integration tests need access to a test org and a test repo. Moreover, due to the nature of runners, I needed a way to expose the GARM API to github so we can receive webhooks. All this makes integration tests more complicated, at least when coupled to github. For Gitea, it should be way easier to implement tbh.

For creating the reverse proxy/tunnel I created a quick and dirty service (probably full of bugs given it was a 1 day project) here:

https://github.com/gabriel-samfira/localshow

To access this, the current integration job needs a secret that contains a SSH private key. Details bellow. But you can also use ngrok (I think there's an action to set it up) or serveo, or FRP, or rathole (not sure if it's maintained anymore). The idea is to temporarily expose the API from behind a NAT somewhere (typical runner setup).

The needed secrets are:

  • GH_OAUTH_TOKEN - this is a PAT with access to the test org and test repo (somewhere on GitHub)
  • LXD_REMOTE_SERVER - this is a remote that you can set for faster LXD image downloads. But it's optional. It defaults to https://cloud-images.ubuntu.com/releases
  • SSH_PRIVATE_KEY - the private key used by the tests to create the reverse proxy tunnel (it uses SSH as a transport).
  • TUNNEL_BASE_URL - the base URL of the tunnel hosting service. Usually it's something like tunnel.example.com, to which the tests generate a random subdomain and you end up with a tunnel like someRandomSubdomain.tunnel.example.com.

The last 2 depend on whether you use the quick and dirty reverse proxy I linked to or not. If you go with something that is actually maintained, you can skip those and just update the job. For gitea you don't even need this. Everything can be on the same machine. Gitea in a container, GARM started up in the background by the tests.

I attempted to make it easier to run locally, but the fact that we need github to be able to send us webhooks to make the tests relevant, makes this tough.

@gabriel-samfira

Copy link
Copy Markdown
Member

There are multiple options for you to choose from, since I didn't force push.

TL;DR; the second option would be desirable

My general preference is as follows:

  • For presentation to the user, I would rather duplicate some code if that makes the interface clearer.
  • Deduplication is preferred whenever possible
  • Deduplication is not preferred if it makes the code hard to read/follow/change later.

In the above case, it's okay to duplicate the handlers and have one Swagger definition per handler, but de-duplicate any common code that can be used by both the get by ID and by friendly name.

@ChristopherHX

ChristopherHX commented Jun 2, 2025

Copy link
Copy Markdown
Contributor Author

de-duplicate any common code that can be used by both the get by ID and by friendly name.

In this case is not much common, since the old code only has a getRepositorybyid call. The other logic does not seem to be useful for sharing between them if they do not share an handler.

  • I literally gave up with running your integration tests locally due to being too fragile/complex, I hope you can run them before merging and inform be about any problems.
    • Maybe it would be easier to do this in a GitHub Actions VM instead of a local ubuntu VM, but debugging is there also a time intensive pain
  • Please do not merge using Merge Commit, I do not want to see every commit message after merge. I would squash myself if needed once there are all blocker are sorted out

The needed secrets are:

  • GH_OAUTH_TOKEN - this is a PAT with access to the test org and test repo (somewhere on GitHub)
  • LXD_REMOTE_SERVER - this is a remote that you can set for faster LXD image downloads. But it's optional. It defaults to https://cloud-images.ubuntu.com/releases
  • SSH_PRIVATE_KEY - the private key used by the tests to create the reverse proxy tunnel (it uses SSH as a transport).
  • TUNNEL_BASE_URL - the base URL of the tunnel hosting service. Usually it's something like tunnel.example.com, to which the tests generate a random subdomain and you end up with a tunnel like someRandomSubdomain.tunnel.example.com.

Hmm, I did need GH_REPO, GH_OWNER, GH_TOKEN and much much much more. Even then after 5 obscure errors and hang ups I do not see any way forward on that front.

EDIT, I seem to often forget that the tests are called integration here, but they look for me like being an e2e test against an external service

@ChristopherHX ChristopherHX marked this pull request as ready for review June 2, 2025 18:31
WillReturnRows(sqlmock.NewRows([]string{"name", "owner"}).AddRow(s.Fixtures.Repos[0].Name, s.Fixtures.Repos[0].Owner))
s.Fixtures.SQLMock.
ExpectQuery(regexp.QuoteMeta("SELECT * FROM `repositories` WHERE (name = ? COLLATE NOCASE and owner = ? COLLATE NOCASE and endpoint_name = ? COLLATE NOCASE) AND `repositories`.`deleted_at` IS NULL ORDER BY `repositories`.`id`,`repositories`.`id` LIMIT ?")).
ExpectQuery(regexp.QuoteMeta("SELECT * FROM `repositories` WHERE (name = ? COLLATE NOCASE and owner = ? COLLATE NOCASE) AND endpoint_name = ? COLLATE NOCASE AND `repositories`.`deleted_at` IS NULL ORDER BY `repositories`.`id` LIMIT ?")).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did I break some logic of garm here?

ORDER BY `repositories`.`id`,`repositories`.`id`

Looked weird

func (suite *GarmSuite) TestRepositoriesByName() {
t := suite.T()

t.Logf("Update repo with repo %s/%s", suite.repo.Owner, suite.repo.Name)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the integration tests I wonder if it passes, should be equal to the TestRepositories, but using the generated clients of the RepositoryByName.

Comment on lines +255 to +259
q = q.Count(&cnt)

if cnt > 1 {
return Repository{}, errors.Wrap(runnerErrors.ErrBadRequest, "multiple repositories with the same name and owner found")
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does garm allow to create the same endpoint_name for GitHub and Gitea at the same time?

At least your comments about credentials make me believe this is true and a possible problem here

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you're referring to endpoints, the name of an edpoint (github, ghes, gitea) is the primary key. So, endpoint names are unique (regardless of type). If you define a GHES endpoint as my first forge, you can't define a gitea endpoint with the same name.

If you're referring to whether you can have the same owner/repo on multiple endpoints, then yes. A repo is unique within the context of an endpoint.

There is no reason why github.com/cloudbase/garm and ghes.example.com/cloudbase/garm could not both exist and be managed by GARM.

@gabriel-samfira

Copy link
Copy Markdown
Member

I literally gave up with running your integration tests locally due to being too fragile/complex, I hope you can run them before merging and inform be about any problems.

Sadly, there is no easy way to run them due to the reasons I detailed earlier. The reliance on GitHub and the need to expose the GARM API to github, makes things difficult. I can run them on a fork.

Please do not merge using Merge Commit, I do not want to see every commit message after merge. I would squash myself if needed once there are all blocker are sorted out

No worries. Once the review is done, you can squash/rebase.

EDIT, I seem to often forget that the tests are called integration here, but they look for me like being an e2e test against an external service

They are end-to-end tests essentially. They started out as integration tests when the project was first created because we needed to test that GARM works well with the external provider we had at the time (LXD). It has since grown, so the tests no longer represent "integration" given that we only test against one of the many providers. But I was too lazy to change the name.

There are references to e2e in the suite. I don't care enough about the terminology to change it, given the current bandwidth.

@gabriel-samfira

Copy link
Copy Markdown
Member

I will try to allocate some time for a proper review in the morning.

@gabriel-samfira gabriel-samfira left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good for the most part. Just a few comments and a few things missing.

Here is an integration test run: https://github.com/gabriel-samfira/garm/actions/runs/15487918541/job/43606694806

Sorry it's taking so long to review.

Comment thread apiserver/controllers/repositories.go Outdated
}
}

// swagger:route GET /repositories/{owner}/{repo}/scalesets Repositories scalesets ListRepoByNameScaleSets

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do you think about naming these types something like ListRepoScaleSetsByFriendlyName ? Similar to the other ByName functions. ListRepoByNameScaleSets feels a bit complicated to remember.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would be regex

  • ([^ \(\.]+Repo)ByName([^ \(]*)(Handler) to $1$2ByFriendlyName$3 (Handler)
  • ([^ \(\.]+Repo)ByName([^ \(]*) to $1$2ByFriendlyName$3 (Swagger)
  • update tests to use new names

If we name this like ListRepoScaleSetsByFriendlyName, then I would expect that I am able to use a friendly name of a scaleset as well instead of a scaleset id.

If this function would accept both friendly and ids of scalesets in future this name makes sense to me. What do you think?

Naming things is not my strength

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Naming things is not my strength

We share that quality.

If we name this like ListRepoScaleSetsByFriendlyName, then I would expect that I am able to use a friendly name of a scaleset as well instead of a scaleset id.

that's true, and is something we will probably do in the future (I would like to name pools as well at some point), but given that pools (right now at least) can only be referenced by ID, I think it would be sufficiently intuitive that consistency is maintained between pools and scale sets, and should not cause confusion.

When we do add the ability to reference scale sets by name, we can keep the name, handler and URL the same and add aditional query arg options to be able to specify runnerGroup (scale set names are unique within a runner group) and endpoint.

Comment thread database/sql/repositories.go Outdated
First(&repo)
Preload("Endpoint")

q = q.First(&repo)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can move this call to First() after the check for Count(). If we don't err out in that condition, we can then execute the extra SELECT. Otherwise, we spare one query.

Comment thread runner/common.go Outdated
repoObj, err := r.store.GetRepository(ctx, owner, repo, endpointName)
if err != nil {
if errors.Is(err, runnerErrors.ErrNotFound) {
return params.Repository{}, runnerErrors.NewBadRequestError("repository %s/%s (%s) not found", owner, repo, endpointName)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is a runnerErrors.NewNotFound() function. Or we can just return the original error that is already an ErrNotFound. Not finding a repo should not be a bad request.

Comment thread apiserver/controllers/common.go Outdated
Comment thread apiserver/controllers/common.go Outdated
Comment thread test/integration/client_utils.go Outdated
}

func updateRepoByName(apiCli *client.GarmAPI, apiAuthToken runtime.ClientAuthInfoWriter, owner, repo string, repoParams params.UpdateEntityParams) (*params.Repository, error) {
updateRepoResponse, err := apiCli.Repositories.UpdateRepoByName(

@gabriel-samfira gabriel-samfira Jun 6, 2025

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You need to add a route in apiserver/routers/routers.go before this will work.

Comment thread apiserver/routers/routers.go Outdated
/////////////////////
// Get pool
apiRouter.Handle("/repositories/{repoID}/pools/{poolID}/", http.HandlerFunc(han.GetRepoPoolHandler)).Methods("GET", "OPTIONS")
apiRouter.Handle("/repositories/{owner}/{repo}/pools/{poolID}/", http.HandlerFunc(han.GetRepoByNamePoolHandler)).Methods("GET", "OPTIONS")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You probably need to add routes for CRUD for repositories as well, not just for repo pools. Same for repo instances.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like your are right

apiRouter.Handle("/repositories/{repoID}/scalesets/", http.HandlerFunc(han.CreateRepoScaleSetHandler)).Methods("POST", "OPTIONS")
apiRouter.Handle("/repositories/{repoID}/scalesets", http.HandlerFunc(han.CreateRepoScaleSetHandler)).Methods("POST", "OPTIONS")
// List scale sets
apiRouter.Handle("/repositories/{repoID}/scalesets/", http.HandlerFunc(han.ListRepoScaleSetsHandler)).Methods("GET", "OPTIONS")
apiRouter.Handle("/repositories/{repoID}/scalesets", http.HandlerFunc(han.ListRepoScaleSetsHandler)).Methods("GET", "OPTIONS")
// Repo instances list
apiRouter.Handle("/repositories/{repoID}/instances/", http.HandlerFunc(han.ListRepoInstancesHandler)).Methods("GET", "OPTIONS")
apiRouter.Handle("/repositories/{repoID}/instances", http.HandlerFunc(han.ListRepoInstancesHandler)).Methods("GET", "OPTIONS")
// Get repo
apiRouter.Handle("/repositories/{repoID}/", http.HandlerFunc(han.GetRepoByIDHandler)).Methods("GET", "OPTIONS")
apiRouter.Handle("/repositories/{repoID}", http.HandlerFunc(han.GetRepoByIDHandler)).Methods("GET", "OPTIONS")
// Update repo
apiRouter.Handle("/repositories/{repoID}/", http.HandlerFunc(han.UpdateRepoHandler)).Methods("PUT", "OPTIONS")
apiRouter.Handle("/repositories/{repoID}", http.HandlerFunc(han.UpdateRepoHandler)).Methods("PUT", "OPTIONS")
// Delete repo
apiRouter.Handle("/repositories/{repoID}/", http.HandlerFunc(han.DeleteRepoHandler)).Methods("DELETE", "OPTIONS")
apiRouter.Handle("/repositories/{repoID}", http.HandlerFunc(han.DeleteRepoHandler)).Methods("DELETE", "OPTIONS")

messed this up.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's ok. It's what reviews and tests are for.

ChristopherHX and others added 4 commits June 6, 2025 12:38
Co-authored-by: Gabriel <samfiragabriel@gmail.com>
* maybe just remove the whole special not found handlling
@gabriel-samfira

Copy link
Copy Markdown
Member

Let me know when you're done making changes so I can take another look.

@ChristopherHX

ChristopherHX commented Jun 10, 2025

Copy link
Copy Markdown
Contributor Author

@gabriel-samfira I found new design issues with {repoid} => {owner}/{repo} that are not present in my intial owner%2frepo and the router patch.

In b45d604 I had to change the router definition order, the first sign of a real conflict.

Moving this one level up, was somehow ok. Is there another big flaw in the routing here?

e.g. /repostitory/{repoid}/webhook
and /repostitory/{owner}/{repo} with repo = webhook is a conflict here.

Growing apis like this is error prone, do we really want to go this way?

Otherwise make this on the cli client, but keep the GetRepositorybyFriendlyName endpoint to grep the id. (no this has a conflict as well, if we do not use something like /repostitoryByFriendlyName/{owner}/{repo} or other prefix)

@gabriel-samfira

Copy link
Copy Markdown
Member

Is there another big flaw in the routing here?

There is a potential of conflict if there is ever a webhook repo under an {owner}.

Growing apis like this is error prone, do we really want to go this way?

We have a few options here, but I need to have the time and mental clarity to think about this and forward a discussion. Growing the API will eventually lead to conflicts, but we have a couple of options we need to weigh the pros and cons of:

  1. Doing this client side.
  • Pros: no changes needed to the backend.
  • Cons: adds multiple API calls (minimum 2, potentially more)
  1. Using query args instead of URL parameters to feed in the owner, name and optional endpoint
  • Pros: It might work (is this even a pro? - going back under my rock)
  • Cons: Not very REST-e for anything other than READ, feels like trying to fit a square in a circle.
  1. adding an /api/v1.1 base route for new functionality.
  • Pros: Allows for growth, clean implementation, old clients continue to function unchanged, new clients know where to request the new functionality
  • Cons: API versioning requires discipline

There might be other options, but I can't allocate time now to proper thought/research.

So, to conclude. You're absolutely right that this can't grow much further without asking for trouble. The conflict you identified is pretty glaring. If you think that at this point it's easier to have a CLI-only feature that emulates what you tried to add with this PR, I am all for it (with a tracking issue opened to potentially implement this server side in the future, via an option that is clean and correct - which can be implemented by whomever has the cycles).

What are your thoughts on this? I am open to anything.

@ChristopherHX

Copy link
Copy Markdown
Contributor Author

Do you know if we can restrict repoid routes by a guid regex? So the router skips guid routes for named repos.

e.g.

https://github.com/gorilla/mux?tab=readme-ov-file#examples
Paths can have variables. They are defined using the format {name} or {name:pattern}. If a regular expression pattern is not defined, the matched variable will be anything until the next slash. For example:

@gabriel-samfira

Copy link
Copy Markdown
Member

I think it's possible. Worth a try. It might be cleaner to version the API, though. Something like:

apiV11SubRouter := router.PathPrefix("/api/v1.1").Subrouter()
apiV11SubRouter.Use(initMiddleware.Middleware)
apiV11SubRouter.Use(urlsRequiredMiddleware.Middleware)
apiV11SubRouter.Use(authMiddleware.Middleware)
apiV11SubRouter.Use(auth.AdminRequiredMiddleware)

apiV11SubRouter.Handle("/repositories/{owner}/{repo}/pools/{poolID}/", http.HandlerFunc(han.GetRepoPoolByFriendlyNameHandler)).Methods("GET", "OPTIONS")
apiV11SubRouter.Handle("/repositories/{owner}/{repo}/pools/{poolID}", http.HandlerFunc(han.GetRepoPoolByFriendlyNameHandler)).Methods("GET", "OPTIONS")
apiV11SubRouter.Handle("/repositories/{owner}/{repo}/pools/{poolID}/", http.HandlerFunc(han.DeleteRepoPoolByFriendlyNameHandler)).Methods("DELETE", "OPTIONS")
apiV11SubRouter.Handle("/repositories/{owner}/{repo}/pools/{poolID}", http.HandlerFunc(han.DeleteRepoPoolByFriendlyNameHandler)).Methods("DELETE", "OPTIONS")
apiV11SubRouter.Handle("/repositories/{owner}/{repo}/pools/{poolID}/", http.HandlerFunc(han.UpdateRepoPoolByFriendlyNameHandler)).Methods("PUT", "OPTIONS")
apiV11SubRouter.Handle("/repositories/{owner}/{repo}/pools/{poolID}", http.HandlerFunc(han.UpdateRepoPoolByFriendlyNameHandler)).Methods("PUT", "OPTIONS")
apiV11SubRouter.Handle("/repositories/{owner}/{repo}/pools/", http.HandlerFunc(han.ListRepoPoolsByFriendlyNameHandler)).Methods("GET", "OPTIONS")

................ The rest of the friendly name URLs ................

The only new code in the example above is defining the new /api/v1.1 prefix (first 5 lines). The rest is copy paste from your PR, with apiRouter substituted for apiV11SubRouter.

Regex might also work for the UUID. I mean, if someone names their repository using uuidgen..... 🤷. There are some things we just don't want to account for tbh. So either way I think is fine.

@ChristopherHX

ChristopherHX commented Jun 11, 2025

Copy link
Copy Markdown
Contributor Author

It might be cleaner to version the API, though. Something like:

Is your idea for versioned api to only allow friendly name in v1.1? I mean otherwise this would only move this routing problem from v1 to v1.1.

Or doing something similar to /repositories/byFriendlyName/<owner>/<repo>/... and /repositories/byID/<owner>/<repo>/... in v1.1 only?

Or just keep clean router for v1?

I mean, if someone names their repository using uuidgen..... 🤷

The uuid filter only applies to repository owner not name.

Basically <uuid>/webhook would still be a problem while <uuid>/<uuid> would not match any uuid route to the repository name.

@ChristopherHX

Copy link
Copy Markdown
Contributor Author
  1. Doing this client side.
  • Pros: no changes needed to the backend.

I assume we agree calling RepoList to query the id is not good.
So I assume we need at least one new backend endpoint

@gabriel-samfira

Copy link
Copy Markdown
Member

Or doing something similar to /repositories/byFriendlyName/<owner>/<repo>/... and /repositories/byID/<owner>/<repo>/... in v1.1 only?

The old router would still exist and we don't need to duplicate old routes that exist there, in v1.1. I am thinking of a union between old and new versions. Hypothetically, say your client wants to run an operation that lists repo pools by friendly name. The client can check the "minimum API version" for that operation and make sure to send that operation against the proper API prefix.

If the client needs to list pools by ID, it knows the minimum API version for that is v1. So the endpoint can be found there.

So only the friendly name URLs would need to exist (currently) in that router. Whatever exists now in v1 would stay there and continue to function as it does now. But this is just an idea.

I assume we agree calling RepoList to query the id is not good. So I assume we need at least one new backend endpoint.

Yes. I suggest adding a filter option to the list repo/org/enterprise endpoints that allows you to do something like:

GET /api/v1/repositories/?owner=repoOwner&name=repoName&endpoint=repoEndpoint

The list repo/org/enterprise handler can look for the query args and adjust the DB query accordingly. This should allow us to return a small subset (min 0, max depending on whether or not endpoint= was set and the number of endpoints that exist with the same combination of owner/repo name).

So either of these 2 approaches are ok. The client side one with minimal changes to the list entity endpoints seems easier. The API versioning one needs more thought and careful planning.

@gabriel-samfira

gabriel-samfira commented Jun 11, 2025

Copy link
Copy Markdown
Member

Yes. I suggest adding a filter option to the list repo/org/enterprise endpoints that allows you to do something like:

Now that I think about it, this would actually be a nice addition. A search function that allows you to filter by owner, endpoint, repo name or all 3 at once. Hah.

@gabriel-samfira

Copy link
Copy Markdown
Member

I based #432 on your PR and merged it.

@ChristopherHX I would like to thank you for pursuing this and for your patience while we figured out how to best solve this. I will close this PR as the same functionality exists in the one I mentioned above.

If you believe the above PR is lacking or not quite what you had in mind, feel free to ping back here. We can improve it or replace it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants